Skip to content

feat(ui): finish the v3 phone, accessibility and polish pass - #485

Merged
chhoumann merged 41 commits into
masterfrom
feat/ui-polish
Oct 15, 2026
Merged

chhoumann merged 41 commits into
masterfrom
feat/ui-polish

Conversation

@chhoumann

@chhoumann chhoumann commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Why

This is the last visual, phone and accessibility pass of the v3 redesign. It also shrinks the bundle, because the parallel units left little room under the 103,000 gzip budget. Each brief item is fixed or has a stated reason below.

What changed

  • On load, PodNotes removes the style sheets an earlier PodNotes bundle injected, in the main window and in popouts. Obsidian updates a plugin without reloading its windows, and Svelte only appends sheets.
  • Phone, accessibility and polish fixes across the view, the full player, the Transcript and Chapters panels and the settings tab (table below).
  • Shared CSS: one button reset (pn-bare), one focus ring, one disabled look, one isPlaying helper, one reveal for both panels, and each component's styles nested under its own root. A tooling test keeps each style owner in one component, puts every :hover behind (hover: hover), and forbids aria-label with title on one element.

Scope

  • Covered: brief items A to F, the coordinator's additions (Set feed address offline, an offline private-feed test, a transcript journey that sets its own layout) and the DOCS6 findings (narrow transcript head, follow offset, two copy fixes).
  • Cut for bytes: iOS long-press menus (+218 gzip). Every row, podcast and playlist menu also opens from a visible button, so iOS users reach every menu. Also cut: the banner's monospace host and sentence split (+52), and the speaker-indent variables (the indent is already aligned).
  • Toggles keep Obsidian's native 30 px on phones, as every Obsidian toggle does; they now carry names.
  • Not judged: frame gaps with the Transcript panel open under Xvfb. They need a profiling pass on GPU hardware.

Item by item

Item Outcome
A. Stale sheets after an in-place update Fixed. The 2.21.1 to 2.22.0 grey icons did not reproduce: 2.21.1's rules are scoped by old hashes or target classes v3 no longer uses. The real defect: a PodNotes sheet survives every later in-place update, and the shell's unanchored rules keep applying until a restart. removePreviousStyles runs first in onload, before any view mounts.
B. Phone panel height, artwork Fixed within Obsidian's chrome: the stage grows from 259.6 to 279.6 px and the artwork from 228 to 248 px. Obsidian's header and nav bar take the rest of the prototype's room.
B. iOS long press Cut for bytes (Scope).
B. Sticky hover Fixed for every :hover rule in the view.
B. Menu-close Escape on phone Fixed: handled in hide(), inside the Escape, not in onHide, which runs after the 150 ms sheet animation.
C. Settings names Fixed: every field and each toggle's focusable label is named after its row.
C. Up next Edit Fixed: at least 44 x 44 on phones.
C. Video overlay focus ring Fixed by the one focus-ring rule.
C. Grid card press Fixed by one rule that stops every transition and animation under reduced motion.
D. Long fallback titles, text size Fixed: three lines that keep the beginning, at least 10 px.
D. "Address not on this device" at 320 Kept #482's wrapping rule; the font split was cut for bytes.
D. Disabled look Fixed: every disabled control dims to 0.4, Obsidian's value for disabled icons. At Obsidian's 0.7 for filled buttons, disabled icons and offline rows read as enabled.
D. MiniPlayer .pn-filetile Fixed: rows, the mini player and the full player draw artwork through Cover.
D. Double tooltip Fixed: no element has both aria-label and title.
D. Follow chip after a resize Fixed: a scroll clamped by a resize no longer turns following off.
D. Speaker indent Not changed (cut list).
D. Dotted capital I in Find Fixed: Find folds U+0130 to "i", so marks keep their offsets.
E. Fullscreen Escape, exit control Fixed: the first Escape leaves fullscreen; the frame's button reads Exit fullscreen there.
E. J-TR-04 Fixed: with a saved transcript note, the panel offers Open note, also offline and when the feed transcript is missing or failed.
F. Tidy Done: pn-bare, one button:focus-visible rule, isPlaying, the tooling test, no pn-queue.
DOCS6 Save folds by the transcript head's own width (320 sidebar, 330 phone drawer); following keeps the current line inside both fades and the line before it in full when it fits, in Transcript and Chapters; "Remove from Local files"; the Download path warning says a second download to the same file fails.

Size

Clean builds, npm run size, gzip level 9.

Step Gzip Delta
master at the start (eafb24b) 101,837
A, F, D, C 101,765 -72
master after #479, #482 (bdf3b82) 102,690
merged, then B, D, E 102,714 +24
styles nested under roots, defaults Obsidian already sets removed 102,474 -240
review round 1 and DOCS6 fixes 102,590 +116
master after #484 (44856a9) 102,362
merged 102,163 -199
review round 2 fixes (one reveal, 0.4 disabled, stage scope, search room) 102,218 +55
review round 3 fixes 102,215 -3
this PR against master 102,215 -147

The -200 aim is missed by 53 bytes. Most of it is the transcript follow rule (+144 over two rounds), which a reviewer showed must account for the current row and both fades.

Mutations

Standing order 32. Each mutant fails at least one test.

  • A: no cleanup; the main window only; cleanup in onunload; cleanup after registerView; cleanup after the first await; also removing other svelte-* sheets. The last three fail only the unit test, because Obsidian mounts restored views after onload returns.
  • E fullscreen: Escape collapses as before; Escape left for the window to exit fullscreen; the exit control hidden in fullscreen; the state heard on the frame instead of the document.
  • E Open note: the lookup through the episode note path; looked up once; the label changes but the click still saves; Open note disabled offline; Open note missing in the failed or missing-transcript state; the paid button beside it.

Verification

Node 22.23.2, Obsidian 1.14.4 (isolated runner, Linux).

  • npm run lint, format:check, typecheck, build, docs:build pass; npm run test 2,729 tests and npm run test:tooling 35 tests pass on the head.
  • npm run test:e2e once on a fresh vault: 219 of 219 tests in 30 files (882200e). The round-3 commits after it touch two e2e files, which pass (settings-tab 9 of 9 from both diarization states; journey-transcript and view-polish 12 of 12).
  • Item A: tests/e2e/update-styles.test.ts loads the 2.21.1 release bundle (pinned SHA-256), updates in place to this build the way Obsidian's updater does, and checks in the main window and a popout that the earlier sheets are gone, another plugin's sheet stays, and the mini player's Play button matches a fresh load. It fails on master.
  • Pixel pass, master 44856a9 against this branch, every state at 320, 820 and phone 390, light and dark: 105 of 142 shots identical; each changed shot is one of the fixes above (phone stage, Edit width, disabled 0.4, transcript and chapters follow, search clear room), or proven noise by reshooting.
  • Three independent review rounds (Claude Fable 5.1 and GPT-6 Astra) and two real-app verification runs; their findings are fixed in this branch.

Each image is master on the left and this PR on the right.

Phone player Phone Up next Phone Transcript
Offline, 320 dark Media error, phone light Add podcast, phone

Fullscreen: entered, after the first Escape, after Exit fullscreen. Saved transcript note: Open note offered, note opened. Phone drawer: folded Save and 44 px Edit.

Follow-ups

  • The panel and TranscriptionService both resolve the transcript note path; the service could expose "open the existing note" to both.
  • isPlaying lives in src/ui; src/player, src/API and the media session keep their own copies.
  • A saved transcript note shows its title twice (inline title and # heading); that is the note template.
  • In the panels, the glyph tails of the row above the first whole row show faintly inside the 12 px top fade; that is the mask against the rows' 6 px padding, not the follow rule.
  • In the phone drawer the transcript list is about 224 px tall, so the row before the current one cannot fit and its speaker name shows cut under the head; the current row wins by design.
  • The verify skill's screens.mjs set ends in phone emulation, and a later test:e2e on that instance fails at once.
  • Once, right after a restart, a view moved to a popout rendered without PodNotes' CSS; it did not reproduce in three tries.

Docs at release

  • faq.md, local_files.md, podcasts.md: "Remove from Local Files" becomes "Remove from Local files".
  • Video: the first Escape in fullscreen only leaves fullscreen; the frame has an Exit fullscreen button.
  • Transcript panel: with a saved transcript note, it shows Open note in place of Save as note or the paid transcription.
  • Podcast menu: Set feed address is unavailable offline.
  • Settings: the Download path warning says a second download to the same file fails.

Note

Finish phone layout, accessibility, and style-cleanup pass for PodcastView

Final polish pass over the podcast player UI.

  • Adds a shared Panel.reveal helper that scrolls the current chapter or transcript row into the visible band without scrolling surrounding content; transcript find matching also uses a new foldCase utility so dotted capital I matches lowercase queries.
  • Makes the full player handle real fullscreen: the first Escape exits fullscreen without collapsing the player, and the fullscreen control toggles enter/exit with matching labels.
  • Delegates artwork fallback rendering in Player and MiniPlayer to the shared Cover component via the new tileOf helper, and adds isPlaying so a loading player counts as playing.
  • Improves accessibility: settings controls get aria-labelledby names from their rows, transcript save buttons keep labels when visually collapsed, native title tooltips are removed, and Escape in menus restores focus immediately. PodNotes.onload now removes stale style sheets from prior builds via removePreviousStyles.
  • Behavioral Change: hover styling is limited to (hover: hover) devices, reduced-motion now suppresses all view transitions, the unavailable episode row no longer gets 45% opacity, flair classes are renamed to pn-flair, the pn-queue class is removed from QueueList, and transcript panels open an existing note instead of saving a duplicate.

Macroscope summarized 6f73df0.

Obsidian updates a plugin in place without reloading its windows, and Svelte
only ever appends a component's sheet, so the sheets of the version before an
update kept applying until a restart. PodNotes now removes its earlier pn-
sheets from the main window and every popout at the start of onload, before
registerView mounts the restored views.

tests/e2e/update-styles.test.ts installs the 2.21.1 release over this build,
plants stand-in stale sheets, then updates back the way Obsidian's updater
does, with the view in the main window and in a popout.
…check

- .pn-bare in the shell carries the reset of Obsidian's button skin that
  fifteen rules repeated, together with the shell's own .pn-back,
  .pn-iconbtn and .pn-navitem. Declarations equal to Obsidian's own button
  rule (border, cursor, font size) go.
- One button:focus-visible rule replaces the list of thirteen selectors.
- isPlaying(status) replaces four copies of the same derivation.
- tests/tooling/componentStyles.test.ts fails when two components scope the
  same class, so a component's sheet only styles what it renders.
- The queue list drops its unused pn-queue class; the e2e targets select it
  by its accessible name.
…s and tooltips

- A cover without artwork shows the first three lines of its name, at 10 px
  or more, so a long name keeps its beginning on a row's 40 px cover.
- The feed banner keeps monospace for a host only; "Address not on this
  device" wraps in the UI font instead of truncating at 320 px.
- Every disabled button uses Obsidian's 0.7 opacity: the 0.35 and 0.45
  overrides go, and an unavailable row dims through its aria-disabled.
- The mini player draws its cover with the rows' Cover and tileOf, so an
  episode without artwork shows its podcast's name as rows do.
- No control has both an aria-label, which Obsidian shows as a tooltip,
  and a title; the tooling test enforces it.
- The transcript's Follow chip no longer appears when a resize clamps the
  list's scroll position, and Find folds a dotted capital I without moving
  its marks.
…uest

- Each settings field is labelled by its row's name through aria-labelledby,
  which Obsidian does not show as a tooltip. Toggles keep Obsidian's native
  30 px size, as every toggle in Obsidian's settings has.
- Up next's Edit, and every other panel head control, is at least 44 px
  wide on a phone, not only 44 px tall.
- One shell rule stops every transition and animation in the view when the
  user prefers reduced motion; the grid card's press had none before. It
  replaces the play button's and the playing bars' own rules.
- styles.test.ts cascades the components' compiled CSS in jsdom: motion
  under the reduced-motion query, and the video play button's focus ring
  drawn inside the frame that clips it.
… phones

- On a phone the deck keeps the desktop's 12 px gaps and 8 px top padding,
  so the stage, and the artwork that fills its height, grow by 20 px under
  Obsidian's header and navbar. A panel already fills the stage.
- Hover on the shell's controls, rows and the deck applies only on a device
  that can hover, so a tapped control does not stay lit on a phone.
- A menu's closing Escape is handled, and focus returns to its control, as
  the menu starts to hide; on a phone the hide callback runs 150 ms later,
  when the Escape is gone.
…the mini player do

The full player kept its own image, failure state and tile. It now uses the
rows' Cover and tileOf: a remote episode without artwork, or whose artwork
fails, shows its podcast's name as rows and the grid do, and a local file
its tile. This saves 111 gzip bytes.
#482 made the banner's detail wrap, so "Address not on this device" no
longer truncates at 320 px. The split that set a sentence in the UI font
and only a host in monospace cost 52 gzip bytes for a font change, so it
goes back to master's single rule; the e2e keeps the no-truncation check.
… transcript note

- In a video's fullscreen the view's Escape now leaves fullscreen and keeps
  the player open; the next Escape closes the player. Obsidian's window does
  not leave element fullscreen on Escape by itself, and closing the player
  only ended fullscreen by removing the frame. The fullscreen button stays
  on the frame in fullscreen as Exit fullscreen.
- The transcript panel offers Open note, offline too, when the episode's
  transcript note already exists at the transcript path, instead of Save as
  note, which the command refuses for an existing note. It follows a note
  made, moved or deleted, and a new transcript path.
- The podcast menu's Set feed address, and Edit feed URL, are disabled
  offline, as the feed banner's button is: an address is checked by loading
  its feed.
…e right sidebar

The journeys opened PodNotes wherever the vault's layout left it. After the
verify skill's screens.mjs had moved the view to the main area or sized the
window, the transcript panel was shorter, the click helper scrolled line 7
into view, and the Follow chip that scroll shows covered it. Each journey
now puts PodNotes in the right sidebar 820 px wide, widening the window if
it has to.
…odes offline

Beside #482's journey for a private feed whose address is missing: one whose
address is on this device, opened offline after its feed was cached, lists
the cached episodes and shows no feed banner and no missing address.
Svelte writes its scoping class into every selector written at a sheet's
top level, but only once for rules nested in a root-anchored
`.root :global { ... }` block. Each component now keeps its root's own
declarations and its state, width and platform variants (`&.is-wide`,
`.is-phone &`) in that one block.

The flattened rules select the same elements with the same specificity,
except two that rise by one class and change nothing: the wide header's
`.pn-head` and `.pn-head-title` rules, which nothing else styles, and
the queue's row-hover rule for Remove, whose only rival in between is
its own focus rule with the same opacity.
A scoped keyframes rule makes Svelte write its scoping class into every
element of the component. EpisodeRow's `pn-eq` bars are the only
keyframes in the view, and put the class on all 17 of the row's
elements. A global `pn-eq`, inside `:global { }`, leaves it on the root.
ChaptersPanel's sheet held one rule, the weight of a chapter's name. Panel
already styles its rows and their time, so it styles the name beside it,
and ChaptersPanel no longer ships a sheet of its own.
…sing

With a transcript note saved before, the Transcript panel offered Open
note only once the feed's transcript loaded. When it did not load (an
offline restart) the panel showed only Retry, and when the feed has none
(the note came from a paid transcription) it offered to transcribe again.
Both now offer Open note, in place of the paid transcription, through the
same button as the panel's head, which keeps its label outside the head.
Obsidian 1.14 focuses a toggle's `label.checkbox-container`, and gives its
checkbox tabindex -1, so the row's name on the checkbox never reached a
screen reader. The label is named after the row too. The unit mock builds
Obsidian's toggle markup, so the test can tell. Each Setting has a fresh
name element, so it always takes a new id.
A touch screen keeps the last tapped element hovered. Eleven `:hover`
rules outside `@media (hover: hover)` stayed lit after a tap, and the
panel switch's hover rule outranked its pressed look, so a tapped switch
on a phone looked hovered rather than on. Every component's hover rules
now sit in that query, and the test walks every component under src/ui
instead of a hand-kept list.
The clamped name had a 10cqw padding inside its overflow clip, so the
line after the third painted into that padding: on the 93 px card of a
320 px view, four lines had their middle inside the clip. A margin puts
the clip edge under the third line and keeps the name where it was. The
e2e test now counts those lines.
…on its button

A phone hides Obsidian's menu after a 150 ms animation, and Obsidian sends
focus from a sidebar to the main area on an Escape nothing handled. The
test opens a row's menu in the right drawer, where PodNotes opens on a
phone, presses Escape with trusted input, and reads focus in the task
after the keydown and 200 ms later: on the row's More button both times.
…d part way

The update-styles test swaps PodNotes' artifact symlinks for files and puts
them back in its finally. An install that failed after removing main.js
made that finally throw ENOENT before relinking it, which left the vault's
PodNotes without a bundle. It now removes with force.
Every element the button rules style is a button, and Obsidian's `button`
rule already sets the same display, alignment, height, radius, font size,
cursor and white-space. No other Obsidian rule that reaches a button in
the view sets those properties, so the cascade resolves to the same
values. Obsidian also hides a search field's cancel button.
…y state

The two states shared their frame, icon and note button, and differ in
the icon, the sentence and whether Retry or the paid transcription shows.
…n's flair

The counts used Obsidian's `.flair` and undid its tag look with five
declarations. A `pn-flair` class needs none of them. What Obsidian's rule
still set does not show here: a radius without a background, a relative
position without an offset, and vertical-align on a flex item.
…nt offline

Offline, a load is not attempted, so the journey that restarts offline
records no failure, and showing every recorded failure offline passed it.
The journey now fails a refresh online first, and checks that going
offline hides that failure behind the Offline strip.
The episode menu said "Remove from Local Files" while the page is "Local
files". The stored playlist keeps its name.
Without {{title}} in the download path, episodes resolve to the same file.
The download code then refuses the second one, with "A file already
exists" when another episode's file is there and "A download is already
in progress" while the first is still writing, so nothing is overwritten.
The warning said downloads overwrite each other.
…s narrow

The label folded away on the view's compact desktop layout, so in a phone's
right drawer, about 330 px wide, "Save as note" ran past the panel's edge.
The panel head is now a size container, and Save folds when the head has
less than the 340 px the phone's label needs: in a 320 px sidebar and in
the drawer, not in a 390 px phone's main area. The CSS mock applies a
container query the way it applies a media query.
Following kept the line playing now a third of the way down the list.
In a phone's right drawer, where the list is 248 px tall, that left the
line before it 7 px above the list and under its faded top edge. The
list declares that 12 px edge as scroll padding, and following now puts
the line a third down or under the whole line before it and that edge,
whichever is lower, but no lower than half way, so a long line before
it does not push it out of view.

The e2e helpers for the phone layout and the right drawer serve the
transcript journeys' three phone tests.
view-polish timed out once waiting for a Latest row. That run reused an
instance on which the verify skill's `screens.mjs offline on` had left
the renderer offline through CDP: no feed loaded, the Latest page stayed
empty, and the next run passed because the suite's offline test turns
the network back on as it ends. Reproduced by the same commands, then a
run of view-polish: "Timed out waiting for episode First Steps shown".

The harness now brings the renderer online before the suite starts, and
the reused-vault lifecycle test leaves it offline to check that.
Following put the transcript's line a third of the way down, lower to
show the whole line before it, but no lower than half way. A tall line
could then end in the faded bottom edge, the half-way cap clipped a line
before it that would have fit, and a third of the way down could leave
the line above the one before it cut under the top fade, as in a 320 px
sidebar. Chapters kept the line a third of the way down and ignored
both fades.

One reveal beside Panel now serves both panels. It puts the row under
as many whole rows as fit in the list's top third, starting where the
top fade ends, so no row shows cut there, and under the whole row before
it even when that one is taller. The row never reaches into the bottom
fade and never starts under the top fade. Each fade's height is one
custom property on the scroller, which the mask and the scroll padding
use and reveal reads.
view-polish and journey-transcript each pasted the CDP phone metrics and
the steps that open PodNotes in a phone's right drawer. asPhone now sits
in the journey actions, and placePodNotes takes a "right drawer" place
that waits until PodNotes shows there. The Escape focus test removes the
global it records focus in.
The test read each field's aria-labelledby and looked the label up
itself, so it passed on markup that names nothing. It now turns on
diarization, whose rows take focus only then, tabs through PodNotes'
settings with trusted key presses, and reads the name Chromium computes
for each field that takes focus, the name a screen reader announces,
and what Chromium took it from. Each must be its row's name, through
aria-labelledby: a focused toggle's label would otherwise take the name
of the checkbox inside it.
The note button carried an aria-label equal to its label, so Obsidian
showed a tooltip repeating "Open note" in the empty panel, where the
label is in sight. The aria-label kept the button named in a narrow
head, which folded the label away with display: none. A narrow head now
clips the label out of sight and keeps it in the accessibility tree, so
the label names the button everywhere and the aria-label is gone.
The rail's add button was the one control whose rule sat outside its
component's root, at the specificity of two classes. On a tablet,
Obsidian's .is-tablet button:not(.clickable-icon) outranks that and gave
it 4 px by 20 px of padding. The rail's styles now nest under its root
like every other component's.
The stage's artwork rules matched every cover in the stage, so where a
narrow player shows Up next in the stage, its rows' covers took the
artwork's drop shadow, and a local file's row tile took the artwork's
icon size. The rules now match only the stage's own child.
…sabled icons

Disabled buttons took Obsidian's 0.7 for buttons, which reads as
enabled, most of all on icon buttons and on an offline row that needs a
connection. One rule in the view now dims a disabled or aria-disabled
button to 0.4, as Obsidian dims a disabled icon. Hover no longer lights
a disabled icon button or call to action, so the icon button's own
disabled rule is gone. Nothing inside a disabled button dims again, and
the scrubber keeps its own 0.5.
On a phone the clear button is 44 px wide, but the field kept the
desktop's 28 px of room on its right, so a pasted link ran into the X.
A phone's field now keeps 44 px on its right.
The container rule that folds Save's label gave no reason for 340 px.
Measured on a phone, the head's icon, title, Free tag, 44 px Find and
labelled Save take 339.8 px.
The one disabled rule dims every disabled control to 0.4, but the
pressed icon button's full opacity outranked it. reveal now says "at
least the row before" with the position it has already taken instead
of reading the DOM again.
… head check reads

The settings test clicked the diarization toggle, which turned it off
on a vault where it was already on, so the diarization rows never took
focus. The transcript head check reports whether Save has an
aria-label; it now says so.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 6f73df0
Status: ✅  Deploy successful!
Preview URL: https://6bb11e3e.podnotes.pages.dev
Branch Preview URL: https://feat-ui-polish.podnotes.pages.dev

View logs

@chhoumann
chhoumann merged commit 75b00ab into master Oct 15, 2026
18 checks passed
@chhoumann
chhoumann deleted the feat/ui-polish branch October 15, 2026 04:49
chhoumann added a commit that referenced this pull request Oct 15, 2026
Applies #485's Docs at release items: Remove from Local files, Exit fullscreen, Open note in the Transcript panel, Set feed address off while offline, and the Download path warning.
chhoumann added a commit that referenced this pull request Oct 15, 2026
Name icon buttons by what a reader sees now that #485 gave them per-episode
names, split the no-transcript paragraph into a list, scope the iPhone
fullscreen sentence, and end phone note crops below the last whole block.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant